simaticml-decoder 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,596 @@
1
+ """Phase 1: SimaticML XML -> model.* (a faithful, behaviour-free mirror).
2
+
3
+ All XML-quirk handling is isolated here so the later phases never touch a raw
4
+ element. Decoder rules from SIMATICML_READING_GUIDE.md that this phase owns:
5
+
6
+ * Parse namespace-aware but match by *local name* (FlgNet is /v5, StructuredText
7
+ is /v4, Interface is /v5 — versions drift; local-name matching is robust).
8
+ * Scope every UId lookup table *per compile unit* — UIds repeat across networks.
9
+ (Each FlgNet owns its own accesses/parts/calls dicts, so this is structural.)
10
+ * Never deduplicate Access nodes — one node per use (each use has a unique UId).
11
+ * Unescape ``"..."`` datatypes and mark them as UDT references.
12
+ (ElementTree unescapes entities; a datatype left wrapped in quotes is a UDT.)
13
+ * Read ``Informative`` values but do not depend on them.
14
+ * Preserve unknown attributes/elements in ``raw`` for forward compatibility.
15
+ * Empty ``<NetworkSource />`` -> Network.source = None.
16
+
17
+ This is the first module implemented, validated against the six V21 samples.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from xml.etree import ElementTree as ET
23
+
24
+ from . import model
25
+
26
+ # --------------------------------------------------------------------------- #
27
+ # Namespace-agnostic element helpers (match by local name only) #
28
+ # --------------------------------------------------------------------------- #
29
+
30
+
31
+ def _ln(tag: object) -> str:
32
+ """Local name of an element tag, dropping any ``{namespace}`` prefix."""
33
+ if not isinstance(tag, str):
34
+ return ""
35
+ return tag.rsplit("}", 1)[-1]
36
+
37
+
38
+ def _children(elem: ET.Element, name: str | None = None) -> list[ET.Element]:
39
+ """Direct child elements, optionally filtered by local name."""
40
+ return [c for c in elem if name is None or _ln(c.tag) == name]
41
+
42
+
43
+ def _child(elem: ET.Element | None, name: str) -> ET.Element | None:
44
+ """First direct child with the given local name, or None."""
45
+ if elem is None:
46
+ return None
47
+ for c in elem:
48
+ if _ln(c.tag) == name:
49
+ return c
50
+ return None
51
+
52
+
53
+ def _child_text(elem: ET.Element | None, name: str) -> str | None:
54
+ c = _child(elem, name)
55
+ return c.text if c is not None else None
56
+
57
+
58
+ def _first_element_child(elem: ET.Element | None) -> ET.Element | None:
59
+ """First child element (ignoring text/whitespace); None if there are none."""
60
+ if elem is None:
61
+ return None
62
+ for c in elem:
63
+ return c
64
+ return None
65
+
66
+
67
+ def _nz(text: str | None) -> str | None:
68
+ """Normalise a metadata string: strip, and collapse empty/blank to None."""
69
+ if text is None:
70
+ return None
71
+ stripped = text.strip()
72
+ return stripped or None
73
+
74
+
75
+ def _is_true(value: str | None) -> bool:
76
+ return (value or "").strip().lower() == "true"
77
+
78
+
79
+ def _int_or_none(value: str | None) -> int | None:
80
+ if value is None:
81
+ return None
82
+ try:
83
+ return int(value.strip())
84
+ except (ValueError, AttributeError):
85
+ return None
86
+
87
+
88
+ def _extra_attrs(elem: ET.Element, known: set[str]) -> dict:
89
+ """Unknown attributes preserved for forward compatibility (the raw hatch)."""
90
+ return {k: v for k, v in elem.attrib.items() if k not in known}
91
+
92
+
93
+ # --------------------------------------------------------------------------- #
94
+ # Entry points #
95
+ # --------------------------------------------------------------------------- #
96
+
97
+
98
+ def parse_document(xml_text: str) -> model.Document:
99
+ """Raw XML string -> model.Document (a faithful syntactic mirror)."""
100
+ root = ET.fromstring(xml_text)
101
+
102
+ engineering = _child(root, "Engineering")
103
+ version = engineering.get("version") if engineering is not None else None
104
+
105
+ block_elem = _find_block_element(root)
106
+ if block_elem is None:
107
+ raise ValueError("no SW.Blocks.* block element found in document")
108
+
109
+ return model.Document(engineering_version=version, block=_parse_block(block_elem))
110
+
111
+
112
+ def parse_file(path: str) -> model.Document:
113
+ """Convenience wrapper: read a file and parse it."""
114
+ with open(path, encoding="utf-8-sig") as fh: # -sig strips the UTF-8 BOM TIA emits
115
+ return parse_document(fh.read())
116
+
117
+
118
+ # --------------------------------------------------------------------------- #
119
+ # Block #
120
+ # --------------------------------------------------------------------------- #
121
+
122
+
123
+ def _find_block_element(root: ET.Element) -> ET.Element | None:
124
+ for child in root:
125
+ if _ln(child.tag).startswith("SW.Blocks."):
126
+ return child
127
+ return None
128
+
129
+
130
+ def _block_kind(tag_local: str) -> model.BlockKind:
131
+ suffix = tag_local.split(".")[-1]
132
+ try:
133
+ return model.BlockKind(suffix)
134
+ except ValueError:
135
+ return model.BlockKind.UNKNOWN
136
+
137
+
138
+ def _parse_block(elem: ET.Element) -> model.Block:
139
+ attrs = _child(elem, "AttributeList")
140
+ objlist = _child(elem, "ObjectList")
141
+
142
+ block = model.Block(
143
+ kind=_block_kind(_ln(elem.tag)),
144
+ id=elem.get("ID", ""),
145
+ name=_nz(_child_text(attrs, "Name")) or "",
146
+ number=_int_or_none(_child_text(attrs, "Number")),
147
+ language=_parse_language(_child_text(attrs, "ProgrammingLanguage")),
148
+ memory_layout=_nz(_child_text(attrs, "MemoryLayout")),
149
+ memory_reserve=_int_or_none(_child_text(attrs, "MemoryReserve")),
150
+ set_eno_automatically=_is_true(_child_text(attrs, "SetENOAutomatically")),
151
+ interface=_parse_interface(_child(attrs, "Interface")),
152
+ )
153
+
154
+ if objlist is not None:
155
+ block.title, block.comment = _titles_and_comments(objlist)
156
+ index = 0
157
+ for cu in _children(objlist, "SW.Blocks.CompileUnit"):
158
+ index += 1
159
+ block.networks.append(_parse_compile_unit(cu, index))
160
+
161
+ return block
162
+
163
+
164
+ def _parse_language(value: str | None) -> model.Language:
165
+ """Per-compile-unit / block language -> model.Language (unknown -> OTHER)."""
166
+ raw = (value or "").strip()
167
+ mapping = {
168
+ "LAD": model.Language.LAD,
169
+ "LAD_IEC": model.Language.LAD,
170
+ "FBD": model.Language.FBD,
171
+ "FBD_IEC": model.Language.FBD,
172
+ "SCL": model.Language.SCL,
173
+ "STL": model.Language.STL,
174
+ "GRAPH": model.Language.GRAPH,
175
+ }
176
+ return mapping.get(raw, model.Language.OTHER)
177
+
178
+
179
+ # --------------------------------------------------------------------------- #
180
+ # Interface (sections + members) #
181
+ # --------------------------------------------------------------------------- #
182
+
183
+
184
+ def _parse_interface(elem: ET.Element | None) -> model.Interface:
185
+ interface = model.Interface()
186
+ if elem is None:
187
+ return interface
188
+ sections = _child(elem, "Sections")
189
+ if sections is None:
190
+ return interface
191
+ for sec in _children(sections, "Section"):
192
+ section = model.Section(name=sec.get("Name", ""))
193
+ for member in _children(sec, "Member"):
194
+ section.members.append(_parse_member(member))
195
+ interface.sections.append(section)
196
+ return interface
197
+
198
+
199
+ _MEMBER_KNOWN_ATTRS = {"Name", "Datatype", "Version", "Remanence", "Accessibility"}
200
+
201
+
202
+ def _parse_member(elem: ET.Element) -> model.Member:
203
+ # ElementTree already unescaped &quot; -> a datatype still wrapped in double
204
+ # quotes is a UDT reference (e.g. '"PLC_System"'). Keep the quotes; flag it.
205
+ datatype = elem.get("Datatype", "")
206
+ is_udt = len(datatype) >= 2 and datatype.startswith('"') and datatype.endswith('"')
207
+
208
+ raw: dict = {}
209
+ accessibility = elem.get("Accessibility")
210
+ if accessibility is not None:
211
+ raw["accessibility"] = accessibility
212
+ extra = _extra_attrs(elem, _MEMBER_KNOWN_ATTRS)
213
+ if extra:
214
+ raw["attrs"] = extra
215
+ attr_list = _child(elem, "AttributeList")
216
+ if attr_list is not None:
217
+ bool_attrs = {
218
+ ba.get("Name"): (ba.text or "").strip()
219
+ for ba in _children(attr_list, "BooleanAttribute")
220
+ }
221
+ if bool_attrs:
222
+ raw["boolean_attributes"] = bool_attrs
223
+
224
+ return model.Member(
225
+ name=elem.get("Name", ""),
226
+ datatype=datatype,
227
+ is_udt=is_udt,
228
+ version=elem.get("Version"),
229
+ start_value=_nz(_child_text(elem, "StartValue")),
230
+ remanence=elem.get("Remanence"),
231
+ comment=_inline_comment(_child(elem, "Comment")),
232
+ children=[_parse_member(c) for c in _children(elem, "Member")],
233
+ raw=raw,
234
+ )
235
+
236
+
237
+ # --------------------------------------------------------------------------- #
238
+ # Compile units / networks #
239
+ # --------------------------------------------------------------------------- #
240
+
241
+
242
+ def _parse_compile_unit(elem: ET.Element, index: int) -> model.Network:
243
+ attrs = _child(elem, "AttributeList")
244
+ objlist = _child(elem, "ObjectList")
245
+
246
+ language = _parse_language(_child_text(attrs, "ProgrammingLanguage"))
247
+ title, comment = (_titles_and_comments(objlist) if objlist is not None else (None, None))
248
+ source = _parse_network_source(_child(attrs, "NetworkSource"), language)
249
+
250
+ return model.Network(
251
+ index=index,
252
+ language=language,
253
+ title=title,
254
+ comment=comment,
255
+ source=source,
256
+ )
257
+
258
+
259
+ def _parse_network_source(elem: ET.Element | None, language: model.Language):
260
+ """NetworkSource -> FlgNet | StructuredText | RawSource | None.
261
+
262
+ An empty ``<NetworkSource />`` (no element children) is a blank network and
263
+ resolves to None.
264
+ """
265
+ if elem is None:
266
+ return None
267
+ container = _first_element_child(elem)
268
+ if container is None:
269
+ return None # empty network
270
+
271
+ local = _ln(container.tag)
272
+ if local == "FlgNet":
273
+ return _parse_flgnet(container)
274
+ if local == "StructuredText":
275
+ return _parse_structured_text(container)
276
+ # STL (StatementList) and GRAPH (Graph) are parsed losslessly but not
277
+ # rendered in v0 — retain the element tree so nothing is dropped.
278
+ return model.RawSource(language=language, element=container)
279
+
280
+
281
+ # --------------------------------------------------------------------------- #
282
+ # FlgNet (LAD/FBD graph) #
283
+ # --------------------------------------------------------------------------- #
284
+
285
+
286
+ def _parse_flgnet(elem: ET.Element) -> model.FlgNet:
287
+ net = model.FlgNet()
288
+
289
+ parts = _child(elem, "Parts")
290
+ if parts is not None:
291
+ for child in parts:
292
+ local = _ln(child.tag)
293
+ if local == "Access":
294
+ access = _parse_access(child)
295
+ net.accesses[access.uid] = access
296
+ elif local == "Part":
297
+ part = _parse_part(child)
298
+ net.parts[part.uid] = part
299
+ elif local == "Call":
300
+ call = _parse_call(child)
301
+ net.calls[call.uid] = call
302
+
303
+ labels = _child(elem, "Labels")
304
+ if labels is not None:
305
+ for decl in _children(labels, "LabelDeclaration"):
306
+ label_el = _child(decl, "Label")
307
+ net.labels.append(
308
+ model.Label(
309
+ uid=decl.get("UId", ""),
310
+ name=(label_el.get("Name", "") if label_el is not None else ""),
311
+ comment=_inline_comment(_child(decl, "Comment")),
312
+ )
313
+ )
314
+
315
+ wires = _child(elem, "Wires")
316
+ if wires is not None:
317
+ for wire in _children(wires, "Wire"):
318
+ net.wires.append(_parse_wire(wire))
319
+
320
+ return net
321
+
322
+
323
+ _ACCESS_KNOWN_ATTRS = {"Scope", "UId"}
324
+
325
+
326
+ def _parse_access(elem: ET.Element) -> model.Access:
327
+ operand: model.Operand = None
328
+ raw: dict = {}
329
+
330
+ for child in elem:
331
+ local = _ln(child.tag)
332
+ if local == "Symbol":
333
+ operand = _parse_symbol(child)
334
+ elif local == "Constant":
335
+ operand = _parse_constant(child)
336
+ elif local == "Address":
337
+ operand = _parse_address(child)
338
+ elif local == "Comment":
339
+ continue
340
+ else:
341
+ # Expression / CallInfo / Instruction / DataType / Reference / ...
342
+ # are not LAD/FBD operands; round-trip them via raw rather than raise.
343
+ raw.setdefault("children", {})[local] = child
344
+
345
+ extra = _extra_attrs(elem, _ACCESS_KNOWN_ATTRS)
346
+ if extra:
347
+ raw["attrs"] = extra
348
+
349
+ return model.Access(
350
+ uid=elem.get("UId", ""),
351
+ scope=elem.get("Scope", ""),
352
+ operand=operand,
353
+ raw=raw,
354
+ )
355
+
356
+
357
+ def _parse_symbol(elem: ET.Element) -> model.Symbol:
358
+ return model.Symbol(components=[_parse_component(c) for c in _children(elem, "Component")])
359
+
360
+
361
+ def _parse_component(elem: ET.Element) -> model.Component:
362
+ slice_access = elem.get("SliceAccessModifier")
363
+ if slice_access in (None, "undef"):
364
+ slice_access = None
365
+ return model.Component(
366
+ name=elem.get("Name", ""),
367
+ slice_access=slice_access,
368
+ access_modifier=elem.get("AccessModifier", "None"),
369
+ simple_access_modifier=elem.get("SimpleAccessModifier", "None"),
370
+ indices=[_parse_access(a) for a in _children(elem, "Access")],
371
+ )
372
+
373
+
374
+ def _parse_constant(elem: ET.Element) -> model.Constant:
375
+ type_el = _child(elem, "ConstantType")
376
+ value_el = _child(elem, "ConstantValue")
377
+ raw: dict = {}
378
+ if type_el is not None and _is_true(type_el.get("Informative")):
379
+ raw["type_informative"] = True
380
+ if value_el is not None and _is_true(value_el.get("Informative")):
381
+ raw["value_informative"] = True
382
+ fmt = {
383
+ sa.get("Name"): sa.text
384
+ for sa in _children(elem, "StringAttribute")
385
+ }
386
+ if fmt:
387
+ raw["format"] = fmt
388
+ return model.Constant(
389
+ type=type_el.text if type_el is not None else None,
390
+ value=value_el.text if value_el is not None else None,
391
+ name=elem.get("Name"),
392
+ raw=raw,
393
+ )
394
+
395
+
396
+ def _parse_address(elem: ET.Element) -> model.Address:
397
+ return model.Address(
398
+ area=elem.get("Area", ""),
399
+ type=elem.get("Type"),
400
+ bit_offset=_int_or_none(elem.get("BitOffset")),
401
+ block_number=_int_or_none(elem.get("BlockNumber")),
402
+ )
403
+
404
+
405
+ _PART_KNOWN_ATTRS = {"Name", "UId", "DisabledENO", "Version"}
406
+
407
+
408
+ def _parse_part(elem: ET.Element) -> model.Part:
409
+ template_values: list[model.TemplateValue] = []
410
+ negated_pins: list[str] = []
411
+ invisible_pins: list[str] = []
412
+ instance: model.Instance | None = None
413
+ equation: str | None = None
414
+ comment: str | None = None
415
+ raw: dict = {}
416
+
417
+ for child in elem:
418
+ local = _ln(child.tag)
419
+ if local == "TemplateValue":
420
+ template_values.append(
421
+ model.TemplateValue(
422
+ name=child.get("Name", ""),
423
+ kind=child.get("Type", ""),
424
+ value=child.text,
425
+ )
426
+ )
427
+ elif local == "Negated":
428
+ negated_pins.append(child.get("Name", ""))
429
+ elif local == "Invisible":
430
+ invisible_pins.append(child.get("Name", ""))
431
+ elif local == "Instance":
432
+ instance = _parse_instance(child)
433
+ elif local == "Equation":
434
+ equation = child.text
435
+ elif local == "Comment":
436
+ comment = _inline_comment(child)
437
+ elif local == "AutomaticTyped":
438
+ raw.setdefault("automatic_typed", []).append(child.get("Name"))
439
+ else:
440
+ raw.setdefault("children", {})[local] = child
441
+
442
+ extra = _extra_attrs(elem, _PART_KNOWN_ATTRS)
443
+ if extra:
444
+ raw["attrs"] = extra
445
+
446
+ return model.Part(
447
+ uid=elem.get("UId", ""),
448
+ name=elem.get("Name", ""),
449
+ disabled_eno=_is_true(elem.get("DisabledENO")),
450
+ version=elem.get("Version"),
451
+ template_values=template_values,
452
+ negated_pins=negated_pins,
453
+ invisible_pins=invisible_pins,
454
+ instance=instance,
455
+ equation=equation,
456
+ comment=comment,
457
+ raw=raw,
458
+ )
459
+
460
+
461
+ def _parse_instance(elem: ET.Element) -> model.Instance:
462
+ return model.Instance(
463
+ scope=elem.get("Scope", ""),
464
+ components=[_parse_component(c) for c in _children(elem, "Component")],
465
+ )
466
+
467
+
468
+ def _parse_call(elem: ET.Element) -> model.Call:
469
+ info = _child(elem, "CallInfo")
470
+ name = ""
471
+ block_type = ""
472
+ instance: model.Instance | None = None
473
+ parameters: list[model.Parameter] = []
474
+
475
+ if info is not None:
476
+ name = info.get("Name", "")
477
+ block_type = info.get("BlockType", "")
478
+ for child in info:
479
+ local = _ln(child.tag)
480
+ if local == "Parameter":
481
+ parameters.append(
482
+ model.Parameter(
483
+ name=child.get("Name", ""),
484
+ section=child.get("Section", ""),
485
+ type=child.get("Type"),
486
+ informative=_is_true(child.get("Informative")),
487
+ )
488
+ )
489
+ elif local == "Instance":
490
+ instance = _parse_instance(child)
491
+
492
+ return model.Call(
493
+ uid=elem.get("UId", ""),
494
+ name=name,
495
+ block_type=block_type,
496
+ instance=instance,
497
+ parameters=parameters,
498
+ comment=_inline_comment(_child(elem, "Comment")),
499
+ )
500
+
501
+
502
+ _ENDPOINT_KINDS = {
503
+ "Powerrail": model.EndpointKind.POWERRAIL,
504
+ "IdentCon": model.EndpointKind.IDENT_CON,
505
+ "NameCon": model.EndpointKind.NAME_CON,
506
+ "OpenCon": model.EndpointKind.OPEN_CON,
507
+ "Openbranch": model.EndpointKind.OPEN_BRANCH,
508
+ }
509
+
510
+
511
+ def _parse_wire(elem: ET.Element) -> model.Wire:
512
+ endpoints: list[model.Endpoint] = []
513
+ for child in elem:
514
+ kind = _ENDPOINT_KINDS.get(_ln(child.tag))
515
+ if kind is None:
516
+ continue
517
+ endpoints.append(
518
+ model.Endpoint(kind=kind, uid=child.get("UId"), pin=child.get("Name"))
519
+ )
520
+ return model.Wire(uid=elem.get("UId", ""), endpoints=endpoints)
521
+
522
+
523
+ # --------------------------------------------------------------------------- #
524
+ # StructuredText (SCL tokenised AST) — parsed losslessly, reconstructed later #
525
+ # --------------------------------------------------------------------------- #
526
+
527
+
528
+ def _parse_structured_text(elem: ET.Element) -> model.StructuredText:
529
+ """Interleaved Access/Token/Text/comment stream.
530
+
531
+ Access nodes are parsed into model.Access (so operand.render and the xref
532
+ work); every other token is retained as its raw element so scl_reconstruct
533
+ (Phase 2) can rebuild the source verbatim. Nothing is dropped.
534
+ """
535
+ items: list[object] = []
536
+ for child in elem:
537
+ if _ln(child.tag) == "Access":
538
+ items.append(_parse_access(child))
539
+ else:
540
+ items.append(child)
541
+ return model.StructuredText(items=items)
542
+
543
+
544
+ # --------------------------------------------------------------------------- #
545
+ # Multilingual text (block / network titles and comments) #
546
+ # --------------------------------------------------------------------------- #
547
+
548
+
549
+ def _titles_and_comments(objlist: ET.Element) -> tuple[str | None, str | None]:
550
+ title: str | None = None
551
+ comment: str | None = None
552
+ for mlt in _children(objlist, "MultilingualText"):
553
+ composition = mlt.get("CompositionName")
554
+ text = _multilingual_text(mlt)
555
+ if composition == "Title":
556
+ title = text
557
+ elif composition == "Comment":
558
+ comment = text
559
+ return title, comment
560
+
561
+
562
+ def _multilingual_text(mlt: ET.Element) -> str | None:
563
+ """Extract the en-US text from a MultilingualText, falling back to the first.
564
+
565
+ MultilingualText > ObjectList > MultilingualTextItem > AttributeList >
566
+ {Culture, Text}. Empty/whitespace text resolves to None.
567
+ """
568
+ objlist = _child(mlt, "ObjectList")
569
+ if objlist is None:
570
+ return None
571
+ fallback: str | None = None
572
+ for item in _children(objlist, "MultilingualTextItem"):
573
+ attrs = _child(item, "AttributeList")
574
+ if attrs is None:
575
+ continue
576
+ culture = (_child_text(attrs, "Culture") or "").strip()
577
+ text = _nz(_child_text(attrs, "Text"))
578
+ if culture == "en-US":
579
+ return text
580
+ if fallback is None:
581
+ fallback = text
582
+ return fallback
583
+
584
+
585
+ def _inline_comment(elem: ET.Element | None) -> str | None:
586
+ """Inline Part/Call/Member Comment: <Comment><MultiLanguageText .../></Comment>."""
587
+ if elem is None:
588
+ return None
589
+ fallback: str | None = None
590
+ for mlt in _children(elem, "MultiLanguageText"):
591
+ text = _nz(mlt.text)
592
+ if mlt.get("Lang") == "en-US":
593
+ return text
594
+ if fallback is None:
595
+ fallback = text
596
+ return fallback
@@ -0,0 +1,75 @@
1
+ """Reconstruct an SCL network from its tokenised AST.
2
+
3
+ SCL networks do not *fold* — they are already textual, just stored as an ordered,
4
+ interleaved stream of Access / Token / comment elements (SIMATICML_READING_GUIDE.md
5
+ "SCL Is a Tokenized AST"). Reconstruction is ordered concatenation, reusing
6
+ operand.render for the Access elements. This is a distinct operation from graph
7
+ folding, hence a separate module.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from xml.etree import ElementTree as ET
13
+
14
+ from . import model, operand
15
+
16
+
17
+ def reconstruct(st: model.StructuredText) -> str:
18
+ """model.StructuredText -> SCL source text.
19
+
20
+ parse._parse_structured_text stores Access uses as model.Access (so operand
21
+ rendering and the xref work) and every other token as its raw element. We
22
+ walk that stream in order and concatenate: Tokens contribute their literal
23
+ text, Access nodes their rendered operand, comments their (wrapped) content.
24
+ """
25
+ out: list[str] = []
26
+ for item in st.items:
27
+ if isinstance(item, model.Access):
28
+ out.append(operand.render(item))
29
+ elif isinstance(item, ET.Element):
30
+ out.append(_render_element(item))
31
+ else: # defensive: anything else, stringify
32
+ out.append(str(item))
33
+ return "".join(out).strip()
34
+
35
+
36
+ def _local(tag: object) -> str:
37
+ return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
38
+
39
+
40
+ def _render_element(elem: ET.Element) -> str:
41
+ local = _local(elem.tag)
42
+
43
+ if local == "Token":
44
+ return elem.get("Text", "")
45
+
46
+ if local in ("LineComment", "Comment", "Comment_G"):
47
+ return _render_comment(elem)
48
+
49
+ if local == "Text":
50
+ return elem.text or ""
51
+
52
+ if local == "Access":
53
+ # A raw (unparsed) Access — e.g. nested inside an Expression. Recurse so
54
+ # its inner Token/Access stream is still emitted rather than dropped.
55
+ return "".join(_render_element(c) for c in elem)
56
+
57
+ if local == "Parameter":
58
+ # Named call parameter inside an SCL call: "name := <wired value>".
59
+ name = elem.get("Name", "")
60
+ inner = "".join(_render_element(c) for c in elem)
61
+ return f"{name}{inner}" if inner else name
62
+
63
+ # Unknown token type: keep whatever text it carries so nothing is lost.
64
+ text = "".join(elem.itertext())
65
+ return text
66
+
67
+
68
+ def _render_comment(elem: ET.Element) -> str:
69
+ """Render an SCL comment. Multi-line content becomes a (* ... *) block."""
70
+ text = "".join(elem.itertext())
71
+ if not text.strip():
72
+ return ""
73
+ if "\n" in text.strip():
74
+ return f"(*{text}*)" if text.startswith("\n") else f"(*\n{text}\n*)"
75
+ return f"// {text.strip()}"