ea 0.6.40 → 0.6.41

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: 164d7f4eec0c15cd5811283a701759887c6b7431337dd9160506d87c9a9ab673
4
- data.tar.gz: 603a2def1016cee9b7747c6131d6e815be09c566d8d4325bc0109d71565b9b5f
3
+ metadata.gz: 648d534db9bc161659ceb4fb001a91dd027c2de38714d64b757ade37728c9608
4
+ data.tar.gz: ad4addbbbc6e2695b44df97431779acd1cf561d2f4e42c20f64dca60eae11230
5
5
  SHA512:
6
- metadata.gz: bcdeea949d117ea448e114300f8f3114d78000fd0212a4e2428e120084957df3bf309fe0bea278303432133c3f91168177b43fab6483088f56df26a7d0cc33f4
7
- data.tar.gz: 847c43fba07dd14374d031f80731cf10656ebb2e1e63f5674a5c0414dffc1024bba9b7e8026a62f93f17a24921cd7ef5da30b9cb2cec1c598f02b6e76e2321d1
6
+ metadata.gz: 5c0d526857ccf9c8c78eca78e954f8cc4332c7abaa8311c53559ded2bb78dbee599e7e2ec800f997108310078f58115e269bee8aed700295922f08b196fd96f8
7
+ data.tar.gz: 63def8e36e609ff0b1a7dc735ae27ae4518de1394009d1c847ecea14da6451ae3e32e40d90c5f8d00bc9795262bc7f85615a1575c23132f0ff46c42dca53e9c7
data/README.adoc CHANGED
@@ -35,6 +35,42 @@ db = Ea.parse("model.qea") #=> Ea::Qea::Database
35
35
  puts "#{db.packages.size} packages, #{db.objects.size} objects"
36
36
  ----
37
37
 
38
+ === Whole and partial XMI loading (pure — no lutaml-uml needed)
39
+
40
+ `Ea::Xmi.load` parses an EA XMI export into the typed Sparx model.
41
+ Whole load is the default and parses the entire file:
42
+
43
+ [source,ruby]
44
+ ----
45
+ require "ea/xmi"
46
+
47
+ model = Ea::Xmi.load("export.xmi")
48
+ ----
49
+
50
+ Partial load parses only the reference closure of the named elements.
51
+ The wanted pairs are `[package, name]`; a nil package matches by name
52
+ in any package. The closure is assembled in memory by
53
+ `Ea::Xmi::Slicer` — the export is streamed once, no temporary files
54
+ are written, and the parse runs over the slice:
55
+
56
+ [source,ruby]
57
+ ----
58
+ model = Ea::Xmi.load("export.xmi", partial: [["bldg", "Building"]])
59
+ ----
60
+
61
+ Partial load produces the same content as whole load for everything
62
+ derived from the model's elements: the slice keeps each wanted class
63
+ with its attributes, operations, connectors, generalization parents,
64
+ package ancestry, and the diagrams that reference kept elements. Use
65
+ it when reading a handful of classes out of a large export — a
66
+ 100 MB export parses as a few MB slice instead of a 10+ GB object
67
+ graph.
68
+
69
+ `Ea::Xmi.load_graph` wraps the same two modes one layer up, returning
70
+ the parsed EA graph: the parser, the parsed document, and the lookup
71
+ indexes the table renders resolve through
72
+ (`Ea::Xmi::Loaded`).
73
+
38
74
  === Generate a SPA from QEA (requires lutaml-uml)
39
75
 
40
76
  [source,bash]
@@ -37,6 +37,36 @@ module Ea
37
37
  context = Context.new(classifier: classifier, **opts)
38
38
  PROVIDERS.flat_map { |provider| provider.call(context) }
39
39
  end
40
+
41
+ # Greedy word wrap measured with the header's own font.
42
+ # EA wraps overflowing headers at word boundaries
43
+ # (81F92FC7: instance header "new ownership: LA_Right"
44
+ # renders as "new ownership:" / "LA_Right").
45
+ def self.wrap_words(text, context, weight)
46
+ font_style = weight == :bold_italic ? "italic" : "normal"
47
+ width = lambda do |s|
48
+ TextRenderer.estimate_width(
49
+ s, context.font_size || 9, nil,
50
+ family: context.family, weight: "700", style: font_style
51
+ ).round
52
+ end
53
+ return [text] if context.bounds_width.nil?
54
+ return [text] if width.call(text) <= context.bounds_width.to_i
55
+
56
+ lines = []
57
+ current = +""
58
+ text.split(" ").each do |word|
59
+ candidate = current.empty? ? word : "#{current} #{word}"
60
+ if !current.empty? && width.call(candidate) > context.bounds_width.to_i
61
+ lines << current
62
+ current = word
63
+ else
64
+ current = candidate
65
+ end
66
+ end
67
+ lines << current unless current.empty?
68
+ lines
69
+ end
40
70
  end
41
71
  end
42
72
  end
@@ -6,8 +6,9 @@ module Ea
6
6
  module Element
7
7
  module HeaderLineProvider
8
8
  # InstanceSpecification: short-circuits the pipeline. The
9
- # entire header for an instance is "name: Classifier" on
10
- # one bold line. No stereotype, no wrap, no parent ghost.
9
+ # header for an instance is "name: Classifier" on one bold
10
+ # line, word-wrapped when it overflows the box (81F92FC7:
11
+ # "new ownership:" / "LA_Right").
11
12
  class InstanceSpec
12
13
  def self.call(context)
13
14
  inst = context.classifier
@@ -17,7 +18,9 @@ module Ea
17
18
  if inst.classifier_name && !inst.classifier_name.empty?
18
19
  label = "#{label}: #{inst.classifier_name}"
19
20
  end
20
- [[label, :bold]]
21
+ HeaderLinePipeline.wrap_words(label, context, :bold).map do |line|
22
+ [line, :bold]
23
+ end
21
24
  end
22
25
  end
23
26
  end
@@ -38,13 +38,18 @@ module Ea
38
38
  def self.wrapped_name_lines(classifier, context, weight)
39
39
  name = display_name(classifier)
40
40
  name = "#{context.foreign_package_name}::#{name}" if context.foreign_package_name
41
- unless context.bounds_width && name.include?("::") &&
42
- name_exceeds_bounds?(name, context, weight)
41
+ unless context.bounds_width && name_exceeds_bounds?(name, context, weight)
43
42
  return [[name, weight]]
44
43
  end
45
44
 
46
- qualifier, base = name.split("::", 2)
47
- [["#{qualifier}::", weight], [base, weight]]
45
+ if name.include?("::")
46
+ qualifier, base = name.split("::", 2)
47
+ [["#{qualifier}::", weight], [base, weight]]
48
+ else
49
+ HeaderLinePipeline.wrap_words(name, context, weight).map do |line|
50
+ [line, weight]
51
+ end
52
+ end
48
53
  end
49
54
 
50
55
  def self.name_exceeds_bounds?(name, context, weight)
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.40"
7
+ VERSION = "0.6.41"
8
8
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xmi"
4
+
5
+ module Ea
6
+ module Xmi
7
+ # The result of loading an export into the EA graph: the parser,
8
+ # the parsed document, and the indexes the consumers resolve
9
+ # through.
10
+ Loaded = Struct.new(:parser, :uml_document, :drop_options,
11
+ :xmi_id_index, keyword_init: true)
12
+
13
+ module Loader
14
+ # Whole load parses the entire export. Partial load parses only
15
+ # the reference closure of the wanted [package, name] pairs,
16
+ # assembled in memory by Ea::Xmi::Slicer - no temporary files.
17
+ # Both modes produce identical content for everything derived
18
+ # from the model's elements; partial load exists so a consumer
19
+ # reading a handful of classes out of a large export never has
20
+ # to hydrate the whole of it. package may be nil, matching by
21
+ # name in any package.
22
+ def self.call(source, partial: nil)
23
+ xml = read(source)
24
+ xml = Slicer.slice(xml, partial) if partial
25
+ ::Xmi::Sparx::Root.parse_xml(xml)
26
+ end
27
+
28
+ def self.graph(source, partial: nil)
29
+ parser = Parser.new
30
+ uml_document = parser.parse(call(source, partial: partial))
31
+ Loaded.new(
32
+ parser: parser,
33
+ uml_document: uml_document,
34
+ drop_options: build_drop_options(parser),
35
+ xmi_id_index: build_xmi_id_index(uml_document),
36
+ )
37
+ end
38
+
39
+ def self.read(source)
40
+ source.respond_to?(:read) ? source.read : File.read(source.to_s)
41
+ end
42
+
43
+ def self.build_drop_options(parser)
44
+ lookup = LookupService.new(parser)
45
+ {
46
+ xmi_root_model: parser.xmi_root_model,
47
+ id_name_mapping: parser.id_name_mapping,
48
+ lookup: lookup,
49
+ with_gen: true,
50
+ with_assoc: true,
51
+ with_absolute_path: true,
52
+ }
53
+ end
54
+
55
+ # One xmi_id -> node walk per parse.
56
+ def self.build_xmi_id_index(uml_document)
57
+ index = {}
58
+ collect = lambda do |container|
59
+ index[container.xmi_id] ||= container if container.respond_to?(:xmi_id)
60
+ if container.respond_to?(:classes)
61
+ container.classes.each { |n| index[n.xmi_id] ||= n }
62
+ end
63
+ if container.respond_to?(:data_types)
64
+ container.data_types.each { |n| index[n.xmi_id] ||= n }
65
+ end
66
+ if container.respond_to?(:enums)
67
+ container.enums.each { |n| index[n.xmi_id] ||= n }
68
+ end
69
+ (container.packages || []).each { |p| collect.call(p) } if container.respond_to?(:packages)
70
+ end
71
+ collect.call(uml_document)
72
+ index
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml"
4
+ require "stringio"
5
+
6
+ module Ea
7
+ module Xmi
8
+ # Prunes an EA XMI export to the subgraphs reachable from sets of
9
+ # (package name, element name) pairs, so the existing parse pipeline
10
+ # hydrates small slices instead of the whole export. A plateau-scale
11
+ # export is 98 MB, more than half of it diagram presentation data
12
+ # that no table reads, and materializing the whole Xmi tree plus the
13
+ # Ea graph costs 10+ GB of live objects.
14
+ #
15
+ # The closure is generic: every attribute value that looks like an EA
16
+ # element id (EAID_/EAPK_/EAEM_) found anywhere inside a kept
17
+ # element's subtree keeps the referenced element too, transitively -
18
+ # no XMI semantics are encoded here. Only container tags are
19
+ # membership-gated (packagedElement in the model, element and
20
+ # connector in the extension); anything nested inside a kept
21
+ # container is copied verbatim. The diagrams section is dropped.
22
+ #
23
+ # `slices` performs one index pass and one write pass and emits one
24
+ # standalone slice per requested group, so each consumer (one macro,
25
+ # one class table) hydrates only its own class's subgraph: the
26
+ # per-parse peak is the size of one slice, not of the export.
27
+ class Slicer
28
+ EA_ID_RE = /\A(?:EAID|EAPK|EAEM)_[0-9A-Za-z_]+\z/
29
+ private_constant :EA_ID_RE
30
+
31
+ XML_DECL = %(<?xml version="1.0" encoding="UTF-8"?>\n)
32
+ private_constant :XML_DECL
33
+
34
+ # SAX passes over ~100 MB EA exports fire millions of events whose
35
+ # per-event strings and hashes outrun the collector (malloc grows
36
+ # for GBs before an automatic cycle). Collect at a fixed event
37
+ # cadence once the malloc budget is blown.
38
+ GC_BUDGET_BYTES = 512 * 1024 * 1024
39
+ private_constant :GC_BUDGET_BYTES
40
+
41
+ def self.gc_when_bloated
42
+ GC.start if GC.stat(:malloc_increase_bytes) > GC_BUDGET_BYTES
43
+ end
44
+
45
+ # Containers whose membership is gated against the keep set, by
46
+ # document section. umldi:Diagram carries the embedded diagram
47
+ # shapes (tens of MB with waypoints and bounds in an EA export)
48
+ # and is never referenced by table content, so it gates on the
49
+ # keep set as well.
50
+ GATED = {
51
+ model: ["packagedElement", "umldi:Diagram"].freeze,
52
+ elements: ["element"].freeze,
53
+ connectors: ["connector"].freeze,
54
+ diagrams: ["diagram"].freeze,
55
+ }.freeze
56
+ private_constant :GATED
57
+
58
+ SECTION_CHILD = {
59
+ "elements" => :elements,
60
+ "connectors" => :connectors,
61
+ "diagrams" => :drop,
62
+ "profiles" => :drop,
63
+ }.freeze
64
+ private_constant :SECTION_CHILD
65
+
66
+ Node = Struct.new(:id, :tag, :xmi_type, :name, :parent_id, :section,
67
+ :refs, :links_refs, keyword_init: true)
68
+
69
+ class << self
70
+ # Single slice for one wanted set, as a string: the in-memory
71
+ # form partial loading consumes.
72
+ # wanted: array of [package_name, element_name]; package may be
73
+ # nil, in which case elements match by name in any package.
74
+ def slice(source, wanted)
75
+ new(source).single(wanted)
76
+ end
77
+
78
+ # One index pass, one write pass, one standalone slice per
79
+ # group, returned as strings. groups: hash key => wanted array
80
+ # (same shape as +wanted+ above). Groups whose wanted set
81
+ # matches nothing are skipped entirely, so callers can fall
82
+ # back to the full source for them.
83
+ def slices(source, groups)
84
+ new(source).multi(groups)
85
+ end
86
+ end
87
+
88
+ def initialize(source)
89
+ @source = source
90
+ @moxml = Moxml.new(:leptris)
91
+ end
92
+
93
+ def single(wanted)
94
+ nodes = index_pass
95
+ keep = closure(nodes, seed_ids(nodes, wanted))
96
+ output = StringIO.new(+"")
97
+ writer = GroupWriter.new([keep], [output])
98
+ @moxml.sax_parse(source_string, writer)
99
+ output.string
100
+ end
101
+
102
+ def multi(groups)
103
+ nodes = index_pass
104
+ keeps = []
105
+ keys = []
106
+ groups.each do |key, wanted|
107
+ seeds = seed_ids(nodes, wanted)
108
+ next if seeds.empty?
109
+
110
+ keeps << closure(nodes, seeds)
111
+ keys << key
112
+ end
113
+ outputs = keys.map { StringIO.new(+"") }
114
+ @moxml.sax_parse(source_string, GroupWriter.new(keeps, outputs))
115
+ keys.zip(outputs).to_h { |k, o| [k, o.string] }
116
+ end
117
+
118
+ private
119
+
120
+ # --- pass one: index id-bearing containers and their references --
121
+
122
+ class IndexHandler < ::Moxml::SAX::Handler
123
+ attr_reader :nodes
124
+
125
+ def initialize
126
+ @nodes = {}
127
+ @stack = [] # [id_or_nil, shared_refs_array]
128
+ @section = []
129
+ @refs_off = 0
130
+ @events = 0
131
+ @links_depth = 0
132
+ end
133
+
134
+ # Only gated containers become nodes (packagedElement in the
135
+ # model, element and connector in the extension, and the
136
+ # embedded umldi:Diagram blocks). Other id-bearing elements -
137
+ # `<type xmi:idref>`, `<model package>` and the like - are pure
138
+ # references: recording them as nodes would let a reference
139
+ # define the element's ancestry, so their EA-id values flow into
140
+ # the enclosing container's reference set instead.
141
+ def on_start_element(name, attributes = {}, _namespaces = {})
142
+ Slicer.gc_when_bloated if (@events += 1) % 50_000 == 0
143
+ @section << section_for(name, @section.last)
144
+ @refs_off += 1 if name == "links"
145
+ @links_depth += 1 if name == "links"
146
+ id = attributes["xmi:id"] || attributes["xmi:idref"]
147
+ is_node = id && GATED.fetch(@section.last, []).include?(name)
148
+ if is_node && (existing = @nodes[id])
149
+ # A container id occurs twice in an EA export: as the model
150
+ # element (uml:Model subtree) and as the extension entry
151
+ # keyed by xmi:idref. Merge so one node carries the model
152
+ # ancestry and both occurrences' references; the entry
153
+ # usually carries the name.
154
+ existing.name ||= attributes["name"]
155
+ existing.xmi_type ||= attributes["xmi:type"]
156
+ collect_refs(attributes, existing.refs)
157
+ @stack << [id, existing.refs]
158
+ return
159
+ end
160
+ # the parent is the nearest id-bearing element already on the
161
+ # stack, so it has to be read before this element is pushed
162
+ parent = nearest_id
163
+ refs = is_node ? [] : (@stack.last ? @stack.last[1] : [])
164
+ @stack << [is_node ? id : nil, refs]
165
+ unless is_node
166
+ collect_refs(attributes, refs)
167
+ return
168
+ end
169
+
170
+ node = Node.new(
171
+ id: id, tag: name,
172
+ xmi_type: attributes["xmi:type"], name: attributes["name"],
173
+ parent_id: parent, section: @section.last, refs: refs,
174
+ links_refs: [],
175
+ )
176
+ @nodes[id] = node
177
+ collect_refs(attributes, node.refs)
178
+ collect_refs(attributes, node.links_refs) if @links_depth.positive?
179
+ node
180
+ end
181
+
182
+ def on_end_element(name)
183
+ @refs_off -= 1 if name == "links"
184
+ @links_depth -= 1 if name == "links" && @links_depth.positive?
185
+ @stack.pop
186
+ @section.pop
187
+ end
188
+
189
+ private
190
+
191
+ def nearest_id
192
+ @stack.reverse_each do |id, _|
193
+ return id if id
194
+ end
195
+ nil
196
+ end
197
+
198
+ def section_for(name, current)
199
+ return :model if name == "uml:Model"
200
+ return :extension if name == "xmi:Extension"
201
+
202
+ if current == :extension
203
+ SECTION_CHILD[name] || :drop
204
+ elsif current == :drop
205
+ :drop
206
+ else
207
+ current
208
+ end
209
+ end
210
+
211
+ # The links blocks inside extension entries restate every
212
+ # relation's endpoints; the connector records in the connectors
213
+ # section already carry both ends, so collecting from links
214
+ # would pull the endpoint classes of every relation into the
215
+ # closure transitively (half the model per class).
216
+ def collect_refs(attributes, refs)
217
+ return if @refs_off.positive?
218
+
219
+ attributes.each_value do |v|
220
+ refs << v if v.is_a?(String) && v.match?(EA_ID_RE)
221
+ end
222
+ end
223
+ end
224
+
225
+ def index_pass
226
+ handler = IndexHandler.new
227
+ @moxml.sax_parse(source_string, handler)
228
+ handler.nodes
229
+ end
230
+
231
+ def source_string
232
+ case @source
233
+ when IO, StringIO
234
+ @source.read
235
+ else
236
+ s = @source.to_s
237
+ # partial loading hands us the export content itself; a path
238
+ # never starts with markup
239
+ s.lstrip.start_with?("<") ? s : File.read(s)
240
+ end
241
+ end
242
+
243
+ # --- closure ------------------------------------------------------
244
+
245
+ def closure(nodes, seeds)
246
+ keep = {}
247
+ frontier = seeds.map { |i| [i, false] }
248
+ until frontier.empty?
249
+ id, via_links = frontier.pop
250
+ next if keep[id]
251
+
252
+ node = nodes[id]
253
+ next unless node
254
+ # links references only buy the connector record they name;
255
+ # following them further pulled every relation's endpoints
256
+ # into a class's closure (half the model per class)
257
+ next if via_links && node.section != :connectors
258
+
259
+ keep[id] = true
260
+ node.refs.each { |r| frontier << [r, false] }
261
+ node.links_refs.each { |r| frontier << [r, true] }
262
+ # keeping a model element keeps its package ancestry
263
+ if node.section == :model
264
+ anc = nodes[node.parent_id]
265
+ while anc && !keep[anc.id]
266
+ keep[anc.id] = true
267
+ anc.refs.each { |r| frontier << [r, false] } unless anc.section == :model
268
+ anc = nodes[anc.parent_id]
269
+ end
270
+ end
271
+ end
272
+ keep_diagrams(nodes, keep)
273
+ keep
274
+ end
275
+
276
+ # The klass tables embed each class's EA diagram; diagrams point
277
+ # INTO the model (their shapes carry modelElement ids), a
278
+ # direction the closure never walks, so diagrams whose shapes
279
+ # reference kept elements are kept in a second pass.
280
+ def keep_diagrams(nodes, keep)
281
+ nodes.each_value do |n|
282
+ next unless n.section == :diagrams && n.tag == "diagram" ||
283
+ (n.section == :model && n.xmi_type == "umldi:Diagram")
284
+
285
+ keep[n.id] = true if n.refs.any? { |r| keep[r] } ||
286
+ n.links_refs.any? { |r| keep[r] }
287
+ end
288
+ end
289
+
290
+ def seed_ids(nodes, wanted)
291
+ seeds = []
292
+ wanted.each do |package, name|
293
+ named = nodes.values.select { |n| n.name == name }
294
+ next if named.empty?
295
+
296
+ # package-anchored matches first; consumers resolve by name,
297
+ # so when no package chain matches, every same-named element
298
+ # is seeded and the resolver picks among them as usual
299
+ anchored = package ? named.select { |n| under_package?(n, package, nodes) } : []
300
+ (anchored.empty? ? named : anchored).each { |n| seeds << n.id }
301
+ end
302
+ seeds.uniq
303
+ end
304
+
305
+ def under_package?(node, package, nodes)
306
+ anc = nodes[node.parent_id]
307
+ while anc && anc.id != node.id
308
+ return true if anc.name == package && anc.xmi_type == "uml:Package"
309
+
310
+ anc = nodes[anc.parent_id]
311
+ end
312
+ false
313
+ end
314
+
315
+ # --- pass two: re-serialize the kept subtrees, per group ----------
316
+
317
+ class GroupWriter < ::Moxml::SAX::Handler
318
+ def initialize(keeps, outputs)
319
+ @keeps = keeps
320
+ @outputs = outputs
321
+ @open = [] # [name, keys] - keys = group indexes keeping this element
322
+ @section = []
323
+ @events = 0
324
+ end
325
+
326
+ def on_start_document
327
+ @outputs.each { |io| io << XML_DECL }
328
+ end
329
+
330
+ def on_start_element(name, attributes = {}, namespaces = {})
331
+ Slicer.gc_when_bloated if (@events += 1) % 50_000 == 0
332
+ section = section_for(name, @section.last)
333
+ @section << section
334
+ parent_keys = @open.empty? ? (0...@keeps.size).to_a : @open.last[1]
335
+ keys =
336
+ if section == :drop
337
+ []
338
+ elsif GATED[section]&.include?(name)
339
+ id = attributes["xmi:id"] || attributes["xmi:idref"]
340
+ if id
341
+ parent_keys.select { |i| @keeps[i][id] }
342
+ else
343
+ parent_keys.dup
344
+ end
345
+ else
346
+ parent_keys
347
+ end
348
+ @open << [name, keys]
349
+ return if keys.empty?
350
+
351
+ tag = +"<#{name}"
352
+ namespaces.each { |p, u| tag << %( xmlns:#{p}="#{esc_attr(u)}") }
353
+ attributes.each { |k, v| tag << %( #{k}="#{esc_attr(v)}") }
354
+ tag << ">"
355
+ keys.each { |i| @outputs[i] << tag }
356
+ end
357
+
358
+ def on_end_element(name)
359
+ frame = @open.pop
360
+ @section.pop
361
+ frame[1].each { |i| @outputs[i] << "</#{name}>" } unless frame[1].empty?
362
+ end
363
+
364
+ def on_characters(text)
365
+ return if @open.empty? || @open.last[1].empty?
366
+
367
+ escaped = esc_text(text)
368
+ @open.last[1].each { |i| @outputs[i] << escaped }
369
+ end
370
+
371
+ def on_cdata(text)
372
+ return if @open.empty? || @open.last[1].empty?
373
+
374
+ chunk = "<![CDATA[#{text}]]>"
375
+ @open.last[1].each { |i| @outputs[i] << chunk }
376
+ end
377
+
378
+ private
379
+
380
+ def section_for(name, current)
381
+ return :model if name == "uml:Model"
382
+ return :extension if name == "xmi:Extension"
383
+
384
+ if current == :extension
385
+ SECTION_CHILD[name] || :drop
386
+ elsif current == :drop
387
+ :drop
388
+ else
389
+ current
390
+ end
391
+ end
392
+
393
+ # XML attribute-value normalization folds raw newlines and tabs
394
+ # into spaces, which would flatten the asciidoc block structure
395
+ # EA stores inside documentation attributes; character
396
+ # references survive normalization, so emit those instead
397
+ def esc_attr(value)
398
+ value.gsub(/[&<>"\n\r\t]/) do |c|
399
+ { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;",
400
+ "\n" => "&#xA;", "\r" => "&#xD;", "\t" => "&#x9;" }[c]
401
+ end
402
+ end
403
+
404
+ def esc_text(value)
405
+ value.gsub(/[&<>]/) do |c|
406
+ { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;" }[c]
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
data/lib/ea/xmi.rb CHANGED
@@ -9,6 +9,9 @@ module Ea
9
9
  module Xmi
10
10
  autoload :Parser, "ea/xmi/parser"
11
11
  autoload :LookupService, "ea/xmi/lookup_service"
12
+ autoload :Slicer, "ea/xmi/slicer"
13
+ autoload :Loader, "ea/xmi/loader"
14
+ autoload :Loaded, "ea/xmi/loader"
12
15
 
13
16
  module LiquidDrops
14
17
  autoload :RootDrop, "ea/xmi/liquid_drops/root_drop"
@@ -43,10 +46,27 @@ module Ea
43
46
  # To get a `Lutaml::Uml::Document` instead (requires the optional
44
47
  # `lutaml-uml` gem), use `Ea::Bridge::XmiToUml.transform(root)`.
45
48
  #
46
- # @param path [String] path to a .xmi file
49
+ # @param path [String, IO] path to a .xmi file, or an IO
50
+ # @param partial [Array, nil] wanted [package, name] pairs; when
51
+ # given, only the reference closure of those elements is parsed,
52
+ # assembled in memory (see Ea::Xmi::Slicer). Partial load
53
+ # produces the same content as whole load for everything derived
54
+ # from the model's elements; it exists so reading a handful of
55
+ # classes out of a large export does not hydrate the whole of
56
+ # it. Whole load is the default. package may be nil, matching by
57
+ # name in any package.
47
58
  # @return [Xmi::Sparx::Root]
48
- def load(path)
49
- ::Xmi::Sparx::Root.parse_xml(File.read(path))
59
+ def load(path, partial: nil)
60
+ Loader.call(path, partial: partial)
61
+ end
62
+
63
+ # Load an export into the parsed EA graph: the parser, the parsed
64
+ # document, and the lookup indexes the table renders resolve
65
+ # through. See #load for whole vs partial loading.
66
+ #
67
+ # @return [Ea::Xmi::Loaded]
68
+ def load_graph(path, partial: nil)
69
+ Loader.graph(path, partial: partial)
50
70
  end
51
71
  end
52
72
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.40
4
+ version: 0.6.41
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -999,8 +999,10 @@ files:
999
999
  - lib/ea/xmi/liquid_drops/package_drop.rb
1000
1000
  - lib/ea/xmi/liquid_drops/root_drop.rb
1001
1001
  - lib/ea/xmi/liquid_drops/source_target_drop.rb
1002
+ - lib/ea/xmi/loader.rb
1002
1003
  - lib/ea/xmi/lookup_service.rb
1003
1004
  - lib/ea/xmi/parser.rb
1005
+ - lib/ea/xmi/slicer.rb
1004
1006
  - lib/ea/xml_escape.rb
1005
1007
  - lib/tasks/fonts.rake
1006
1008
  - lib/tasks/svg_bench.rake