@aryacode/dsh-engineering 0.4.7 → 0.4.20

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.
Files changed (160) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +34 -26
  3. package/cordis.patch.yml +7 -6
  4. package/entries/config.js +62 -0
  5. package/entries/ftview.js +5 -4
  6. package/entries/l5x.js +47 -15
  7. package/entries/mwi.js +26 -6
  8. package/entries/optix.js +105 -0
  9. package/lib/client.js +335 -6
  10. package/package.json +11 -10
  11. package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
  12. package/vendor/mwi/README.md +39 -18
  13. package/vendor/mwi/SKILL.md +26 -20
  14. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -2763
  15. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
  16. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
  17. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
  18. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
  19. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
  20. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
  21. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
  22. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
  23. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
  24. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
  25. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
  26. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +128 -128
  27. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
  28. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
  29. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
  30. package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
  31. package/vendor/mwi/lib/core/code-engine.js +18 -1
  32. package/vendor/mwi/lib/edit.d.ts +1 -1
  33. package/vendor/mwi/lib/edit.js +1 -1
  34. package/vendor/mwi/lib/read/service.js +8 -1
  35. package/vendor/mwi/lib/session/backup.d.ts +55 -0
  36. package/vendor/mwi/lib/session/backup.js +232 -0
  37. package/vendor/mwi/lib/session/project.d.ts +4 -1
  38. package/vendor/mwi/lib/session/project.js +13 -2
  39. package/vendor/mwi/lib/session/service.d.ts +13 -6
  40. package/vendor/mwi/lib/session/service.js +127 -36
  41. package/vendor/mwi/lib/session/workspace.d.ts +10 -0
  42. package/vendor/mwi/lib/session/workspace.js +53 -3
  43. package/vendor/mwi/lib/skill.d.ts +2 -2
  44. package/vendor/mwi/lib/skill.js +2 -2
  45. package/vendor/mwi/lib/tools/edit.d.ts +3 -3
  46. package/vendor/mwi/lib/tools/edit.js +14 -15
  47. package/vendor/mwi/lib/tools/read.js +1 -1
  48. package/vendor/mwi/lib/tools/session.js +13 -9
  49. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
  50. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
  51. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
  52. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
  53. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  54. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
  55. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
  56. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
  57. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
  58. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
  59. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  60. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  61. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
  62. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
  63. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  64. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
  65. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
  66. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
  67. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
  68. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
  69. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
  70. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  71. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
  72. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
  73. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  74. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
  75. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
  76. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
  77. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
  78. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
  79. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
  80. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  81. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
  82. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
  83. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
  84. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
  85. package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
  86. package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  87. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
  88. package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
  89. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
  90. package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
  91. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
  92. package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  93. package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  94. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
  95. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
  96. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  97. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
  98. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
  99. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
  100. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
  101. package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
  102. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
  103. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  104. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
  105. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
  106. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
  107. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
  108. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
  109. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
  110. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
  111. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
  112. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
  113. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  114. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
  115. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
  116. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
  117. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
  118. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
  119. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
  120. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
  121. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
  122. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
  123. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
  124. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
  125. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
  126. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
  127. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
  128. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
  129. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
  130. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
  131. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
  132. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
  133. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
  134. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
  135. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  136. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
  137. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
  138. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
  139. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  141. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  142. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  143. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  144. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  145. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  146. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  147. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  148. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  149. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  150. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  151. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  152. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  153. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  154. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  155. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  156. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  157. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  158. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  159. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  160. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
@@ -1,399 +1,399 @@
1
- """Pure text transforms for MotionWorks IEC variable declarations.
2
-
3
- These functions operate on the ``.VB`` declaration text only. That is
4
- sufficient to add, edit and delete variables: measured empirically, the compiler
5
- reads declarations from this stream and does **not** require the binary ``.VGR``
6
- grid to be updated (see docs/tier2-format-notes.md). A `.VB`-only edit compiled
7
- clean with the grid left byte-identical.
8
-
9
- Keeping the transforms pure -- text in, text out -- means they can be unit
10
- tested without touching a project, and the write layer only has to handle
11
- backup, atomic replacement and read-back verification.
12
-
13
- Observed format (CRLF, latin-1, tab-indented)::
14
-
15
- <blank>
16
- (*Group:Default*)
17
- <blank>
18
- <blank>
19
- VAR_EXTERNAL
20
- \tTopCutter :\tAXIS_REF;(*SGD7S - 1 (Do Not Modify!!) *)
21
- \tEIP_FromCLX_Axis_SVON_Cmd :\tBOOL;
22
- END_VAR
23
-
24
- Blocks with the same keyword may repeat, each optionally preceded by its own
25
- ``(*Group:...*)`` marker. MotionWorks writes a blank line between blocks.
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import re
31
- from dataclasses import dataclass
32
-
33
- #: Declaration blocks this module can address. ``VAR`` is the default for new
34
- #: local variables; ``VAR_EXTERNAL`` is for symbols owned by another POU.
35
- BLOCK_KEYWORDS = (
36
- "VAR_GLOBAL",
37
- "VAR_EXTERNAL",
38
- "VAR_INPUT",
39
- "VAR_OUTPUT",
40
- "VAR_IN_OUT",
41
- "VAR_TEMP",
42
- "VAR_RETAIN",
43
- "VAR_CONFIG",
44
- "VAR_ACCESS",
45
- "VAR",
46
- )
47
-
48
- _INDENT = "\t"
49
- _SEPARATOR = " :\t"
50
-
51
-
52
- class DeclarationError(ValueError):
53
- """Raised when a declaration cannot be parsed or edited safely."""
54
-
55
-
56
- @dataclass
57
- class Block:
58
- """A ``VAR``-family block located in the declaration text."""
59
-
60
- keyword: str
61
- start: int # index of the keyword line
62
- end: int # index of the matching END_VAR line
63
- lines: list[str] # the block's lines, inclusive
64
-
65
- @property
66
- def is_empty(self) -> bool:
67
- return self.end == self.start + 1
68
-
69
-
70
- def _line_ending(text: str) -> str:
71
- return "\r\n" if "\r\n" in text else "\n"
72
-
73
-
74
- def split_lines(text: str) -> tuple[list[str], str]:
75
- """Split into lines without terminators, plus the dominant line ending."""
76
- ending = _line_ending(text)
77
- normalised = text.replace("\r\n", "\n")
78
- return normalised.split("\n"), ending
79
-
80
-
81
- def join_lines(lines: list[str], ending: str) -> str:
82
- return ending.join(lines)
83
-
84
-
85
- def find_blocks(lines: list[str]) -> list[Block]:
86
- """Locate every declaration block, in order."""
87
- blocks: list[Block] = []
88
- index = 0
89
- while index < len(lines):
90
- stripped = lines[index].strip()
91
- upper = stripped.upper()
92
- keyword = None
93
- for candidate in BLOCK_KEYWORDS:
94
- if upper == candidate or upper.startswith(candidate + " "):
95
- keyword = candidate
96
- break
97
- if keyword is None:
98
- index += 1
99
- continue
100
- # Find the matching END_VAR.
101
- end = index + 1
102
- while end < len(lines) and lines[end].strip().upper() != "END_VAR":
103
- end += 1
104
- if end >= len(lines):
105
- raise DeclarationError(
106
- f"unterminated {keyword} block starting at line {index + 1}"
107
- )
108
- blocks.append(
109
- Block(keyword=keyword, start=index, end=end, lines=lines[index : end + 1])
110
- )
111
- index = end + 1
112
- return blocks
113
-
114
-
115
- def format_declaration(
116
- name: str,
117
- type_name: str,
118
- address: str | None = None,
119
- initial_value: str | None = None,
120
- description: str | None = None,
121
- ) -> str:
122
- """Render one declaration line the way MotionWorks writes it."""
123
- left = name
124
- if address:
125
- left = f"{name} AT {address}"
126
- text = f"{_INDENT}{left}{_SEPARATOR}{type_name}"
127
- if initial_value:
128
- text += f" := {initial_value}"
129
- text += ";"
130
- if description:
131
- text += f"(*{description}*)"
132
- return text
133
-
134
-
135
- def _find_declaration_line(lines: list[str], name: str) -> int:
136
- """Return the line index declaring ``name``, or raise."""
137
- pattern = re.compile(rf"^[ \t]*{re.escape(name)}[ \t]*(?:AT[ \t]+\S+[ \t]*)?:",
138
- re.IGNORECASE)
139
- matches = [i for i, line in enumerate(lines) if pattern.match(line)]
140
- if not matches:
141
- raise DeclarationError(f"variable {name!r} is not declared")
142
- if len(matches) > 1:
143
- raise DeclarationError(
144
- f"variable {name!r} is declared on {len(matches)} lines "
145
- f"({[m + 1 for m in matches]}); refusing to guess"
146
- )
147
- return matches[0]
148
-
149
-
150
- #: Address prefixes and the IEC elementary types each can legitimately hold.
151
- #: ``%IX``/``%QX`` are bit addresses, so they must be BOOL. A word address can
152
- #: hold any numeric type of the matching width or narrower.
153
- _ADDRESS_CLASSES: tuple[tuple[str, set[str] | None], ...] = (
154
- ("%IX", {"BOOL"}),
155
- ("%QX", {"BOOL"}),
156
- ("%MX", {"BOOL"}),
157
- ("%IW", None),
158
- ("%QW", None),
159
- ("%MW", None),
160
- ("%ID", None),
161
- ("%QD", None),
162
- ("%MD", None),
163
- ("%IL", None),
164
- ("%QL", None),
165
- ("%ML", None),
166
- ("%IB", None),
167
- ("%QB", None),
168
- ("%MB", None),
169
- )
170
-
171
-
172
- def address_class_types(address: str) -> set[str] | None:
173
- """Return the types an address may hold, or None when unconstrained.
174
-
175
- None means "not a bit address", i.e. a numeric width that we do not attempt
176
- to police; the callers only use this to catch the clear contradiction of
177
- putting a non-BOOL type on a ``%IX``/``%QX``/``%MX`` bit address.
178
- """
179
- upper = address.strip().upper()
180
- for prefix, allowed in _ADDRESS_CLASSES:
181
- if upper.startswith(prefix):
182
- return allowed
183
- return None
184
-
185
-
186
- def check_address_type(address: str | None, type_name: str) -> str | None:
187
- """Return a warning when ``type_name`` is impossible at ``address``.
188
-
189
- MotionWorks binds a ``%IX``/``%QX``/``%MX`` address to a single bit, so
190
- declaring it as anything but BOOL cannot compile. This was observed
191
- directly: changing an I/O-mapped ``BOOL AT %IX21488.5`` to ``DINT`` made the
192
- build fail (``IsCompiled=False``, no outputs), while the same type change on
193
- an unaddressed variable compiled cleanly.
194
- """
195
- if not address:
196
- return None
197
- allowed = address_class_types(address)
198
- if allowed is None:
199
- return None
200
- if type_name.strip().upper() not in allowed:
201
- return (
202
- f"type {type_name!r} cannot be bound to the bit address {address!r}; "
203
- f"only {', '.join(sorted(allowed))} is valid there"
204
- )
205
- return None
206
-
207
-
208
- def find_referencing_pous(
209
- variable: str, bodies: dict[str, str]
210
- ) -> list[str]:
211
- """Return the names of POUs whose body references ``variable``.
212
-
213
- Deleting a variable that code still uses produces a dangling reference and
214
- the build fails; this was observed directly (deleting a POU-local variable
215
- made the build fail while the same delete of an unreferenced variable
216
- compiled). Checking first turns a confusing build error into a clear
217
- refusal.
218
- """
219
- pattern = re.compile(rf"(?<![A-Za-z0-9_.]){re.escape(variable)}(?![A-Za-z0-9_])")
220
- return sorted(name for name, body in bodies.items() if pattern.search(body))
221
-
222
-
223
- def add_variable(
224
- text: str,
225
- name: str,
226
- type_name: str,
227
- section: str = "VAR",
228
- address: str | None = None,
229
- initial_value: str | None = None,
230
- description: str | None = None,
231
- group: str | None = None,
232
- ) -> tuple[str, list[str]]:
233
- """Add a declaration, returning ``(new_text, notes)``.
234
-
235
- Appends to the last block of the requested ``section``; if there is none,
236
- a new block is appended at the end of the text. Refuses if the name is
237
- already declared, because a duplicate would be a confusing build error.
238
- """
239
- section = section.upper()
240
- if section not in BLOCK_KEYWORDS:
241
- raise DeclarationError(
242
- f"unknown section {section!r}; expected one of {BLOCK_KEYWORDS}"
243
- )
244
- if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
245
- raise DeclarationError(f"invalid variable name {name!r}")
246
- if not type_name.strip():
247
- raise DeclarationError("type name is required")
248
-
249
- lines, ending = split_lines(text)
250
- notes: list[str] = []
251
-
252
- try:
253
- _find_declaration_line(lines, name)
254
- except DeclarationError as exc:
255
- if "not declared" not in str(exc):
256
- raise
257
- else:
258
- raise DeclarationError(f"variable {name!r} is already declared")
259
-
260
- declaration = format_declaration(
261
- name, type_name, address, initial_value, description
262
- )
263
- blocks = find_blocks(lines)
264
- def block_group(block):
265
- current = None
266
- for line in lines[:block.start]:
267
- match = re.match(r"^\s*\(\*Group:\s*(.*?)\*\)", line)
268
- if match: current = match.group(1)
269
- return current
270
- target = next((b for b in reversed(blocks) if b.keyword == section
271
- and (group is None or block_group(b) == group)), None)
272
- if group is not None and target is None:
273
- raise DeclarationError(f"No native {section} block for group {group!r}")
274
-
275
- if target is not None:
276
- lines.insert(target.end, declaration)
277
- else:
278
- notes.append(f"no {section} block existed; appended a new one")
279
- # Separate with exactly one blank line, reusing a trailing blank if the
280
- # file already ends with blanks rather than adding another.
281
- #
282
- # This is what makes add and delete symmetric. Appending unconditionally
283
- # puts the new block below a growing tail of blanks, and delete cannot tell
284
- # that tail apart from blanks the file legitimately ended with -- a stress
285
- # matrix caught exactly that as stray lines left behind on every cycle.
286
- if lines and lines[-1].strip():
287
- lines.append("")
288
- lines.append(section)
289
- lines.append(declaration)
290
- lines.append("END_VAR")
291
- return join_lines(lines, ending), notes
292
-
293
-
294
- def edit_variable(
295
- text: str,
296
- name: str,
297
- new_name: str | None = None,
298
- type_name: str | None = None,
299
- address: str | None = None,
300
- initial_value: str | None = None,
301
- description: str | None = None,
302
- clear_address: bool = False,
303
- ) -> tuple[str, list[str]]:
304
- """Rewrite a variable's declaration line, returning ``(new_text, notes)``.
305
-
306
- Only the fields supplied are changed. ``clear_address`` exists because
307
- ``address=None`` means "leave the address alone", so removing an ``AT``
308
- clause needs an explicit flag rather than an ambiguous default.
309
- """
310
- from .variables import parse_declarations
311
-
312
- lines, ending = split_lines(text)
313
- index = _find_declaration_line(lines, name)
314
- # The declaration parser expects a complete block, so wrap the single line
315
- # in a temporary one rather than parsing a bare fragment.
316
- probe = "\r\nVAR\r\n" + lines[index] + "\r\nEND_VAR\r\n"
317
- existing = parse_declarations(probe, source_stream=None)
318
- candidate = next(
319
- (v for v in existing.variables if v.name.casefold() == name.casefold()), None
320
- )
321
- if candidate is None:
322
- raise DeclarationError(f"could not parse the declaration of {name!r}")
323
- current = candidate
324
-
325
- if new_name and new_name != name:
326
- try:
327
- _find_declaration_line(lines, new_name)
328
- except DeclarationError as exc:
329
- if "not declared" not in str(exc):
330
- raise
331
- else:
332
- raise DeclarationError(f"variable {new_name!r} already exists")
333
-
334
- final_name = new_name or current.name
335
- final_type = type_name if type_name is not None else current.type_name
336
- if clear_address:
337
- final_address = None
338
- elif address is not None:
339
- final_address = address
340
- else:
341
- final_address = current.address
342
- final_initial = (
343
- initial_value if initial_value is not None else current.initial_value
344
- )
345
-
346
- # Description handling: an empty string clears it, None preserves it.
347
- if description is None:
348
- final_description = current.description
349
- else:
350
- final_description = description or None
351
-
352
- lines[index] = format_declaration(
353
- final_name, final_type, final_address, final_initial, final_description
354
- )
355
- notes = [f"rewrote line {index + 1}"]
356
- return join_lines(lines, ending), notes
357
-
358
-
359
- def delete_variable(
360
- text: str,
361
- name: str,
362
- remove_empty_blocks: bool = True,
363
- ) -> tuple[str, list[str]]:
364
- """Remove a declaration, returning ``(new_text, notes)``.
365
-
366
- By default an emptied block is removed too, so the text does not accumulate
367
- stray ``VAR``/``END_VAR`` pairs. MotionWorks tolerates empty blocks, but
368
- leaving them behind makes later diffs noisy.
369
- """
370
- lines, ending = split_lines(text)
371
- index = _find_declaration_line(lines, name)
372
- del lines[index]
373
- notes = [f"removed the declaration of {name!r}"]
374
-
375
- if remove_empty_blocks:
376
- blocks = find_blocks(lines)
377
- removed: list[str] = []
378
- for block in reversed(blocks):
379
- if block.end == block.start + 1:
380
- # Take the blank padding immediately in front of the block, but
381
- # stop at any real content -- and never walk past the start of the
382
- # text.
383
- #
384
- # An earlier version also consumed a preceding ``(*Group:...*)``
385
- # marker and every blank before it. On a POU whose declaration
386
- # file contains only a group marker (a real case: a POU with no
387
- # variables), removing a just-added block then emptied the whole
388
- # file, destroying the marker. A group marker is content that was
389
- # never ours to delete.
390
- start = block.start
391
- while start > 1 and not lines[start - 1].strip():
392
- start -= 1
393
- del lines[start : block.end + 1]
394
- removed.append(block.keyword)
395
- if removed:
396
- notes.append(
397
- "removed now-empty block(s): " + ", ".join(reversed(removed))
398
- )
399
- return join_lines(lines, ending), notes
1
+ """Pure text transforms for MotionWorks IEC variable declarations.
2
+
3
+ These functions operate on the ``.VB`` declaration text only. That is
4
+ sufficient to add, edit and delete variables: measured empirically, the compiler
5
+ reads declarations from this stream and does **not** require the binary ``.VGR``
6
+ grid to be updated (see docs/tier2-format-notes.md). A `.VB`-only edit compiled
7
+ clean with the grid left byte-identical.
8
+
9
+ Keeping the transforms pure -- text in, text out -- means they can be unit
10
+ tested without touching a project, and the write layer only has to handle
11
+ backup, atomic replacement and read-back verification.
12
+
13
+ Observed format (CRLF, latin-1, tab-indented)::
14
+
15
+ <blank>
16
+ (*Group:Default*)
17
+ <blank>
18
+ <blank>
19
+ VAR_EXTERNAL
20
+ \tTopCutter :\tAXIS_REF;(*SGD7S - 1 (Do Not Modify!!) *)
21
+ \tEIP_FromCLX_Axis_SVON_Cmd :\tBOOL;
22
+ END_VAR
23
+
24
+ Blocks with the same keyword may repeat, each optionally preceded by its own
25
+ ``(*Group:...*)`` marker. MotionWorks writes a blank line between blocks.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass
32
+
33
+ #: Declaration blocks this module can address. ``VAR`` is the default for new
34
+ #: local variables; ``VAR_EXTERNAL`` is for symbols owned by another POU.
35
+ BLOCK_KEYWORDS = (
36
+ "VAR_GLOBAL",
37
+ "VAR_EXTERNAL",
38
+ "VAR_INPUT",
39
+ "VAR_OUTPUT",
40
+ "VAR_IN_OUT",
41
+ "VAR_TEMP",
42
+ "VAR_RETAIN",
43
+ "VAR_CONFIG",
44
+ "VAR_ACCESS",
45
+ "VAR",
46
+ )
47
+
48
+ _INDENT = "\t"
49
+ _SEPARATOR = " :\t"
50
+
51
+
52
+ class DeclarationError(ValueError):
53
+ """Raised when a declaration cannot be parsed or edited safely."""
54
+
55
+
56
+ @dataclass
57
+ class Block:
58
+ """A ``VAR``-family block located in the declaration text."""
59
+
60
+ keyword: str
61
+ start: int # index of the keyword line
62
+ end: int # index of the matching END_VAR line
63
+ lines: list[str] # the block's lines, inclusive
64
+
65
+ @property
66
+ def is_empty(self) -> bool:
67
+ return self.end == self.start + 1
68
+
69
+
70
+ def _line_ending(text: str) -> str:
71
+ return "\r\n" if "\r\n" in text else "\n"
72
+
73
+
74
+ def split_lines(text: str) -> tuple[list[str], str]:
75
+ """Split into lines without terminators, plus the dominant line ending."""
76
+ ending = _line_ending(text)
77
+ normalised = text.replace("\r\n", "\n")
78
+ return normalised.split("\n"), ending
79
+
80
+
81
+ def join_lines(lines: list[str], ending: str) -> str:
82
+ return ending.join(lines)
83
+
84
+
85
+ def find_blocks(lines: list[str]) -> list[Block]:
86
+ """Locate every declaration block, in order."""
87
+ blocks: list[Block] = []
88
+ index = 0
89
+ while index < len(lines):
90
+ stripped = lines[index].strip()
91
+ upper = stripped.upper()
92
+ keyword = None
93
+ for candidate in BLOCK_KEYWORDS:
94
+ if upper == candidate or upper.startswith(candidate + " "):
95
+ keyword = candidate
96
+ break
97
+ if keyword is None:
98
+ index += 1
99
+ continue
100
+ # Find the matching END_VAR.
101
+ end = index + 1
102
+ while end < len(lines) and lines[end].strip().upper() != "END_VAR":
103
+ end += 1
104
+ if end >= len(lines):
105
+ raise DeclarationError(
106
+ f"unterminated {keyword} block starting at line {index + 1}"
107
+ )
108
+ blocks.append(
109
+ Block(keyword=keyword, start=index, end=end, lines=lines[index : end + 1])
110
+ )
111
+ index = end + 1
112
+ return blocks
113
+
114
+
115
+ def format_declaration(
116
+ name: str,
117
+ type_name: str,
118
+ address: str | None = None,
119
+ initial_value: str | None = None,
120
+ description: str | None = None,
121
+ ) -> str:
122
+ """Render one declaration line the way MotionWorks writes it."""
123
+ left = name
124
+ if address:
125
+ left = f"{name} AT {address}"
126
+ text = f"{_INDENT}{left}{_SEPARATOR}{type_name}"
127
+ if initial_value:
128
+ text += f" := {initial_value}"
129
+ text += ";"
130
+ if description:
131
+ text += f"(*{description}*)"
132
+ return text
133
+
134
+
135
+ def _find_declaration_line(lines: list[str], name: str) -> int:
136
+ """Return the line index declaring ``name``, or raise."""
137
+ pattern = re.compile(rf"^[ \t]*{re.escape(name)}[ \t]*(?:AT[ \t]+\S+[ \t]*)?:",
138
+ re.IGNORECASE)
139
+ matches = [i for i, line in enumerate(lines) if pattern.match(line)]
140
+ if not matches:
141
+ raise DeclarationError(f"variable {name!r} is not declared")
142
+ if len(matches) > 1:
143
+ raise DeclarationError(
144
+ f"variable {name!r} is declared on {len(matches)} lines "
145
+ f"({[m + 1 for m in matches]}); refusing to guess"
146
+ )
147
+ return matches[0]
148
+
149
+
150
+ #: Address prefixes and the IEC elementary types each can legitimately hold.
151
+ #: ``%IX``/``%QX`` are bit addresses, so they must be BOOL. A word address can
152
+ #: hold any numeric type of the matching width or narrower.
153
+ _ADDRESS_CLASSES: tuple[tuple[str, set[str] | None], ...] = (
154
+ ("%IX", {"BOOL"}),
155
+ ("%QX", {"BOOL"}),
156
+ ("%MX", {"BOOL"}),
157
+ ("%IW", None),
158
+ ("%QW", None),
159
+ ("%MW", None),
160
+ ("%ID", None),
161
+ ("%QD", None),
162
+ ("%MD", None),
163
+ ("%IL", None),
164
+ ("%QL", None),
165
+ ("%ML", None),
166
+ ("%IB", None),
167
+ ("%QB", None),
168
+ ("%MB", None),
169
+ )
170
+
171
+
172
+ def address_class_types(address: str) -> set[str] | None:
173
+ """Return the types an address may hold, or None when unconstrained.
174
+
175
+ None means "not a bit address", i.e. a numeric width that we do not attempt
176
+ to police; the callers only use this to catch the clear contradiction of
177
+ putting a non-BOOL type on a ``%IX``/``%QX``/``%MX`` bit address.
178
+ """
179
+ upper = address.strip().upper()
180
+ for prefix, allowed in _ADDRESS_CLASSES:
181
+ if upper.startswith(prefix):
182
+ return allowed
183
+ return None
184
+
185
+
186
+ def check_address_type(address: str | None, type_name: str) -> str | None:
187
+ """Return a warning when ``type_name`` is impossible at ``address``.
188
+
189
+ MotionWorks binds a ``%IX``/``%QX``/``%MX`` address to a single bit, so
190
+ declaring it as anything but BOOL cannot compile. This was observed
191
+ directly: changing an I/O-mapped ``BOOL AT %IX21488.5`` to ``DINT`` made the
192
+ build fail (``IsCompiled=False``, no outputs), while the same type change on
193
+ an unaddressed variable compiled cleanly.
194
+ """
195
+ if not address:
196
+ return None
197
+ allowed = address_class_types(address)
198
+ if allowed is None:
199
+ return None
200
+ if type_name.strip().upper() not in allowed:
201
+ return (
202
+ f"type {type_name!r} cannot be bound to the bit address {address!r}; "
203
+ f"only {', '.join(sorted(allowed))} is valid there"
204
+ )
205
+ return None
206
+
207
+
208
+ def find_referencing_pous(
209
+ variable: str, bodies: dict[str, str]
210
+ ) -> list[str]:
211
+ """Return the names of POUs whose body references ``variable``.
212
+
213
+ Deleting a variable that code still uses produces a dangling reference and
214
+ the build fails; this was observed directly (deleting a POU-local variable
215
+ made the build fail while the same delete of an unreferenced variable
216
+ compiled). Checking first turns a confusing build error into a clear
217
+ refusal.
218
+ """
219
+ pattern = re.compile(rf"(?<![A-Za-z0-9_.]){re.escape(variable)}(?![A-Za-z0-9_])")
220
+ return sorted(name for name, body in bodies.items() if pattern.search(body))
221
+
222
+
223
+ def add_variable(
224
+ text: str,
225
+ name: str,
226
+ type_name: str,
227
+ section: str = "VAR",
228
+ address: str | None = None,
229
+ initial_value: str | None = None,
230
+ description: str | None = None,
231
+ group: str | None = None,
232
+ ) -> tuple[str, list[str]]:
233
+ """Add a declaration, returning ``(new_text, notes)``.
234
+
235
+ Appends to the last block of the requested ``section``; if there is none,
236
+ a new block is appended at the end of the text. Refuses if the name is
237
+ already declared, because a duplicate would be a confusing build error.
238
+ """
239
+ section = section.upper()
240
+ if section not in BLOCK_KEYWORDS:
241
+ raise DeclarationError(
242
+ f"unknown section {section!r}; expected one of {BLOCK_KEYWORDS}"
243
+ )
244
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
245
+ raise DeclarationError(f"invalid variable name {name!r}")
246
+ if not type_name.strip():
247
+ raise DeclarationError("type name is required")
248
+
249
+ lines, ending = split_lines(text)
250
+ notes: list[str] = []
251
+
252
+ try:
253
+ _find_declaration_line(lines, name)
254
+ except DeclarationError as exc:
255
+ if "not declared" not in str(exc):
256
+ raise
257
+ else:
258
+ raise DeclarationError(f"variable {name!r} is already declared")
259
+
260
+ declaration = format_declaration(
261
+ name, type_name, address, initial_value, description
262
+ )
263
+ blocks = find_blocks(lines)
264
+ def block_group(block):
265
+ current = None
266
+ for line in lines[:block.start]:
267
+ match = re.match(r"^\s*\(\*Group:\s*(.*?)\*\)", line)
268
+ if match: current = match.group(1)
269
+ return current
270
+ target = next((b for b in reversed(blocks) if b.keyword == section
271
+ and (group is None or block_group(b) == group)), None)
272
+ if group is not None and target is None:
273
+ raise DeclarationError(f"No native {section} block for group {group!r}")
274
+
275
+ if target is not None:
276
+ lines.insert(target.end, declaration)
277
+ else:
278
+ notes.append(f"no {section} block existed; appended a new one")
279
+ # Separate with exactly one blank line, reusing a trailing blank if the
280
+ # file already ends with blanks rather than adding another.
281
+ #
282
+ # This is what makes add and delete symmetric. Appending unconditionally
283
+ # puts the new block below a growing tail of blanks, and delete cannot tell
284
+ # that tail apart from blanks the file legitimately ended with -- a stress
285
+ # matrix caught exactly that as stray lines left behind on every cycle.
286
+ if lines and lines[-1].strip():
287
+ lines.append("")
288
+ lines.append(section)
289
+ lines.append(declaration)
290
+ lines.append("END_VAR")
291
+ return join_lines(lines, ending), notes
292
+
293
+
294
+ def edit_variable(
295
+ text: str,
296
+ name: str,
297
+ new_name: str | None = None,
298
+ type_name: str | None = None,
299
+ address: str | None = None,
300
+ initial_value: str | None = None,
301
+ description: str | None = None,
302
+ clear_address: bool = False,
303
+ ) -> tuple[str, list[str]]:
304
+ """Rewrite a variable's declaration line, returning ``(new_text, notes)``.
305
+
306
+ Only the fields supplied are changed. ``clear_address`` exists because
307
+ ``address=None`` means "leave the address alone", so removing an ``AT``
308
+ clause needs an explicit flag rather than an ambiguous default.
309
+ """
310
+ from .variables import parse_declarations
311
+
312
+ lines, ending = split_lines(text)
313
+ index = _find_declaration_line(lines, name)
314
+ # The declaration parser expects a complete block, so wrap the single line
315
+ # in a temporary one rather than parsing a bare fragment.
316
+ probe = "\r\nVAR\r\n" + lines[index] + "\r\nEND_VAR\r\n"
317
+ existing = parse_declarations(probe, source_stream=None)
318
+ candidate = next(
319
+ (v for v in existing.variables if v.name.casefold() == name.casefold()), None
320
+ )
321
+ if candidate is None:
322
+ raise DeclarationError(f"could not parse the declaration of {name!r}")
323
+ current = candidate
324
+
325
+ if new_name and new_name != name:
326
+ try:
327
+ _find_declaration_line(lines, new_name)
328
+ except DeclarationError as exc:
329
+ if "not declared" not in str(exc):
330
+ raise
331
+ else:
332
+ raise DeclarationError(f"variable {new_name!r} already exists")
333
+
334
+ final_name = new_name or current.name
335
+ final_type = type_name if type_name is not None else current.type_name
336
+ if clear_address:
337
+ final_address = None
338
+ elif address is not None:
339
+ final_address = address
340
+ else:
341
+ final_address = current.address
342
+ final_initial = (
343
+ initial_value if initial_value is not None else current.initial_value
344
+ )
345
+
346
+ # Description handling: an empty string clears it, None preserves it.
347
+ if description is None:
348
+ final_description = current.description
349
+ else:
350
+ final_description = description or None
351
+
352
+ lines[index] = format_declaration(
353
+ final_name, final_type, final_address, final_initial, final_description
354
+ )
355
+ notes = [f"rewrote line {index + 1}"]
356
+ return join_lines(lines, ending), notes
357
+
358
+
359
+ def delete_variable(
360
+ text: str,
361
+ name: str,
362
+ remove_empty_blocks: bool = True,
363
+ ) -> tuple[str, list[str]]:
364
+ """Remove a declaration, returning ``(new_text, notes)``.
365
+
366
+ By default an emptied block is removed too, so the text does not accumulate
367
+ stray ``VAR``/``END_VAR`` pairs. MotionWorks tolerates empty blocks, but
368
+ leaving them behind makes later diffs noisy.
369
+ """
370
+ lines, ending = split_lines(text)
371
+ index = _find_declaration_line(lines, name)
372
+ del lines[index]
373
+ notes = [f"removed the declaration of {name!r}"]
374
+
375
+ if remove_empty_blocks:
376
+ blocks = find_blocks(lines)
377
+ removed: list[str] = []
378
+ for block in reversed(blocks):
379
+ if block.end == block.start + 1:
380
+ # Take the blank padding immediately in front of the block, but
381
+ # stop at any real content -- and never walk past the start of the
382
+ # text.
383
+ #
384
+ # An earlier version also consumed a preceding ``(*Group:...*)``
385
+ # marker and every blank before it. On a POU whose declaration
386
+ # file contains only a group marker (a real case: a POU with no
387
+ # variables), removing a just-added block then emptied the whole
388
+ # file, destroying the marker. A group marker is content that was
389
+ # never ours to delete.
390
+ start = block.start
391
+ while start > 1 and not lines[start - 1].strip():
392
+ start -= 1
393
+ del lines[start : block.end + 1]
394
+ removed.append(block.keyword)
395
+ if removed:
396
+ notes.append(
397
+ "removed now-empty block(s): " + ", ".join(reversed(removed))
398
+ )
399
+ return join_lines(lines, ending), notes